package cn.boodqian.airdata;

import java.util.ArrayList;
import java.util.Calendar;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.TimeZone;
import java.util.logging.Logger;

import org.apache.commons.lang3.time.DateUtils;

import com.google.appengine.api.datastore.DatastoreService;
import com.google.appengine.api.datastore.DatastoreServiceFactory;
import com.google.appengine.api.datastore.EntityNotFoundException;
import com.google.appengine.api.datastore.Key;
import com.google.appengine.api.datastore.KeyFactory;
import com.google.appengine.api.datastore.Entity;
import com.google.appengine.api.datastore.Text;
import com.google.gson.Gson;

import java.util.Collections;
import net.sf.jsr107cache.Cache;
import net.sf.jsr107cache.CacheException;
import net.sf.jsr107cache.CacheFactory;
import net.sf.jsr107cache.CacheManager;

public class AirdataCache {
	private final static Logger Log = Logger.getLogger(AirdataCache.class.getName());
	
	// For JSON string stored in datastore, i.e. data(store) cache
	private final static String datacacheKind = "Datacache";
	
	private static int slotDifference( long msDiff, int field ) {
		int slot = 0;
		switch( field ) {
		case Calendar.HOUR_OF_DAY:
			slot = (int) (msDiff/DateUtils.MILLIS_PER_HOUR);
			break;
		case Calendar.DAY_OF_MONTH:
			slot = (int) (msDiff/DateUtils.MILLIS_PER_DAY);
			break;
		case Calendar.MONTH:
			float floatDiff = msDiff;
			slot = Math.round((floatDiff/(DateUtils.MILLIS_PER_DAY*30)));
			break;
		default:
			String msg = "Unknown field: " + field;
			assert false:msg;
			Log.severe(msg);
		}
		return slot;
	}
	public static int fieldDuration( int field ) {
		int duration = 0;
		switch( field ) {
		case Calendar.HOUR_OF_DAY:
			duration = 24;
			break;
		case Calendar.DAY_OF_MONTH:
			duration = 30;
			break;
		case Calendar.MONTH:
			duration = 12;
			break;
		default:
			String msg = "Unknown field: " + field;
			assert false:msg;
			Log.severe(msg);
		}
		return duration;
	}
	public static String fieldToString(int field) {
		String key = null;
		switch( field ) {
		case Calendar.HOUR_OF_DAY:
			key = "";
			break;
		case Calendar.DAY_OF_MONTH:
			key = "_Day";
			break;
		case Calendar.MONTH:
			key = "_Month";
			break;
		default:
			String msg = "Unknown field: " + field;
			assert false:msg;
			Log.severe(msg);
		}
		return key;
	}
	private static String formKey(String a, String b, int field) {
	    String key = (a+"_"+b+fieldToString(field));
	    return key;
	}
	private static String formKey(String a, String b) {
	    return (a+"_"+b);
	}
	public static String getString(String kind, String name) {
	    String ret = getStringFromCache(kind, name);
	    if( ret == null ) {
	        ret = getStringFromDataStore(kind, name);
	        putStringToCache(kind, name, ret);
	    }
	    return ret;
	}
	public static String getStringFromCache(String kind, String name) {
	    String ret = null;
	    try {
	        CacheFactory cacheFactory = CacheManager.getInstance().getCacheFactory();
	        Cache cache = cacheFactory.createCache(Collections.emptyMap());

	        String key = formKey(kind, name);
	        ret = (String) cache.get(key);
	        if( ret == null ) {
	            Log.info(String.format("Cache miss for {%s}", key));
	        } else {
	            Log.info(String.format("Cache hit for {%s}", key));
	        }
	    } catch (CacheException e) {
	        Log.warning("Get cache failed: " + e.toString());
	    }
	    return ret;
	}
	public static void putStringToCache(String kind, String name, String value) {
	    String key = formKey(kind, name);
	    try {
	        CacheFactory cacheFactory = CacheManager.getInstance().getCacheFactory();
	        Cache cache = cacheFactory.createCache(Collections.emptyMap());

	        cache.put(key, value);
	        Log.info(String.format("Cache updated for {%s}", key));
	    } catch (CacheException e) {
	        Log.warning(String.format("Cache update for {%s} failed:%s\n", key, e.toString()));
	    }
	}
	private static String getStringFromDataStore(String kind, String name) {
		DatastoreService datastore = DatastoreServiceFactory.getDatastoreService();
		Entity entity = null;
		Key key = KeyFactory.createKey(kind, name);
		String ret = null;
		Log.info(String.format("Query Datastore for {%s, %s}", kind, name));
		try {
			entity = datastore.get(key);
			ret = ((Text) entity.getProperty("value")).getValue();
		} catch (EntityNotFoundException e) {
			Log.warning( "Query Datastore Failed: " + e.toString() );
		}
		return ret;
	}

	public static void putStringToDataStore(String kind, String name, String value) {
		Log.info(String.format("Update Datastore for {%s, %s}", kind, name));
		
	    DatastoreService datastore = DatastoreServiceFactory.getDatastoreService();
		Key key = KeyFactory.createKey(kind, name);
		Entity entity = new Entity(key);
		entity.setUnindexedProperty("value", new Text(value));
		datastore.put(entity);
		
        putStringToCache(kind, name, value);
	}
	
	public static void invalidStringCache(String kind, String name) {
		try {
			CacheFactory cacheFactory = CacheManager.getInstance().getCacheFactory();
			Cache cache = cacheFactory.createCache(Collections.emptyMap());
			
			String key = formKey(kind, name);
			cache.remove(key);
			
			Log.info(String.format("Cache invalidated for {%s}", key));
		} catch (CacheException e) {
			Log.warning("Get cache failed: " + e.toString());
		}
	}
	
	// AirData layer below
	// Use the String layer (String Cache+String Datastore) as the cache
	public static AirData getAirdataFromCache(String locId, int field, int hour) {
		String []a = locId.split("/");
		if( a.length != 2 ) return new AirData();
		return getAirdataFromCache(a[0], a[1], field, hour);
	}
	
	public static AirData getAirdataFromCache(String city, String location, int field, int hour) {
	    AirData data;
	    String dataStr;

	    String key = formKey(city, location, field);
	    dataStr = getString(datacacheKind, key);
	    if( dataStr == null ) {
	        data = null;
	    } else {
	        //Log.info(dataStr);
	        data = new Gson().fromJson(dataStr, AirData.class);
	        data.cutTo(hour);
	    }

	    return data;
	}
	
	public static void putAirdataToCache(String city, String location, int field, AirData airdata) {
	    String key = formKey(city, location, field);
	    String dataStr = new Gson().toJson(airdata);
	    
	    // Maybe not so straight forward here
	    // Not calling putStringToCache since we need the data store cache being updated too
	    putStringToDataStore(datacacheKind, key, dataStr);	        
	}
	
	public static AirData merge(AirData airdata, Map<String,Object> datamap, Date newDate, int field) {
		Calendar newCal = Calendar.getInstance(TimeZone.getTimeZone("GMT+8"));
		newCal.setTime(newDate);
		newCal = DateUtils.truncate(newCal, field);
		
		AirData newairdata = new AirData();
		newairdata.setTime(newCal.getTime());
		
		// Nothing to merge, just build AirData from datamap
		if( airdata == null || airdata.getTime() == null ) {
			for(String type:Global.AirType) {
				ArrayList<Float> newArray = new ArrayList<Float>();
				newArray.add( (Float) datamap.get(type) );
				newairdata.addData(type, newArray);
			}
			return newairdata;
		}
		
		Calendar oldCal = Calendar.getInstance(TimeZone.getTimeZone("GMT+8"));
		oldCal.setTime(airdata.getTime());
		oldCal =  DateUtils.truncate( oldCal, field );
		long msDiff = newCal.getTimeInMillis() - oldCal.getTimeInMillis();
		int slotDiff = slotDifference( msDiff, field );
		Log.fine("slotDiff = " + slotDiff);
		
		if( slotDiff < 0 ) return null;
		if( slotDiff == 0 ) return null; // Cache already updated
		for(String type:Global.AirType) {
		    if( !datamap.containsKey(type) ) continue;
		    Float newData = Float.parseFloat( datamap.get(type).toString() );
		    ArrayList<Float> newArray = new ArrayList<Float>();
		    List<Float> oldArray = airdata.getData(type);
		    if( oldArray == null ) {
		        Float[] array = new Float[airdata.getLength()];
		        java.util.Arrays.fill(array, 0f);
		        oldArray = java.util.Arrays.asList( array );
		    }
		    
		    int beginIndex = oldArray.size() - (fieldDuration(field) - slotDiff);
		    /* OLD: |----|
		     * ADD:                   |-|
		     * NEW:     |----------|
		     */
		    if( beginIndex >= 0 && beginIndex < oldArray.size() ) { // beginIndex within the range
		        newArray.addAll( oldArray.subList( beginIndex, oldArray.size() ) ); // 24 - hourDiff entries
		        for( int i = 0; i < slotDiff - 1; i++ ) // Missing hours, fill with 0f
		            newArray.add(0f);
		        newArray.add(newData);
		    }
		    /* OLD:     |----|
		     * ADD:               |-|
		     * NEW: |----------|
		     */
		    else if( beginIndex < 0 ) {
		        newArray.addAll( oldArray ); // 24 - hourDiff entries
		        for( int i = 0; i < slotDiff - 1; i++ ) // Missing hours, fill with 0f
		            newArray.add(0f);
		        newArray.add(newData);
		    }
		    /* OLD: |----|
		     * ADD:                          |-|
		     * NEW:            |----------|
		     */
		    else { // beginIndex > oldArray.size()
		        for( int i = 0; i < fieldDuration(field) - 1; i++ ) // Missing hours, fill with 0f
		            newArray.add(0f);
		        newArray.add(newData);
		    }
		    newairdata.addData(type, newArray);
		}
		return newairdata;
	}
	
	// Interface for cache updating
	public static void updateAirdataCache(String city, String loc, int field, HashMap<String,Object>datamap, Date newDate)
	{
		AirData cachedData = AirdataCache.getAirdataFromCache(city, loc, field, fieldDuration(field));
		if( cachedData == null ) cachedData = new AirData();
		Date cachedDate = cachedData.getTime();
		if( cachedDate == null ) cachedDate = new Date(0);

		Log.info(String.format("Cache date of location %s: %s, new date: %s ", loc, Global.FmtUTC8.format(cachedDate), Global.FmtUTC8.format(newDate)));

		if( cachedData.getLength() > 0 ) {
			// See if we have non-zero data already for this hour/day/month
			boolean isSame = DateUtils.isSameInstant( DateUtils.truncate(cachedDate, field),
					DateUtils.truncate(newDate, field) );
			if( isSame ) {
				Log.info("No update needed: " + fieldToString(field) );
				return;
			}
		}

		// merge with cached data then update cache
		AirData newdata = AirdataCache.merge(cachedData, datamap, newDate, field);
		if(newdata != null)
			AirdataCache.putAirdataToCache(city, loc, field, newdata);
	}
}
